Skip to content

feat(lamzu): add the Atlantis generation under CompX vendor id 0x3554 - #85

Closed
bpavlina wants to merge 6 commits into
OpenMouse-Project:mainfrom
bpavlina:feat/lamzu-atlantis
Closed

feat(lamzu): add the Atlantis generation under CompX vendor id 0x3554#85
bpavlina wants to merge 6 commits into
OpenMouse-Project:mainfrom
bpavlina:feat/lamzu-atlantis

Conversation

@bpavlina

@bpavlina bpavlina commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Adds the Lamzu Atlantis family — Atlantis OG V2, Atlantis Mini, Atlantis Mini Pro, Thorn, Maya and Paro of that generation — as a driver of its own.

These are not the Lamzu the existing driver handles. LamzuHidClient speaks the CompX page/command protocol on feature report 0 under vendor ids 0x373e and 0x37b0. The Atlantis generation is a different CompX stack entirely: vendor id 0x3554, report 8, interrupt reports, settings in a flash image addressed by byte offset.

That stack is already in this repo — it is what PulsarHidClient speaks to the Pulsar 4K receiver. Same pulsarPacketChecksum, same command ids, same flash offsets, same 50-step DPI encoding. So this adds no new framing code:

// src/lamzu/atlantis.ts
export const LAMZU_ATLANTIS_COMMAND = PULSAR_COMMAND;
export const LAMZU_ATLANTIS_FLASH = {
  ...PULSAR_FLASH,      // reportRate 0, currentDpi 4, liftOffDistance 10, …
  dpiStageCount: 2,     // fields Lamzu's firmware uses that the Pulsar driver never reads
  dpiStageColors: 44,
  buttonActions: 96,
  highPerformance: 185,
} as const;

Why a separate driver rather than widening Pulsar

0x3554 is CompX's ODM id, shared by four brands in this repo already. Today a Lamzu Atlantis falls through PulsarHidClient's VGN branch, so it connects as a "Pulsar" device with a lift-off reading that is simply wrong. Adding the Lamzu ids to CLAIMED_VGN_PRODUCT_IDS — the mechanism already there for exactly this — hands them to a driver that knows what they are.

flowchart TD
  D["HID device<br/>vid 0x3554"] --> C{product id}
  C -->|f520 f523 f5bb f522| T[Teevolution]
  C -->|fb56 fb57| V[VGN F2]
  C -->|f58e f58f| A[ATK / VXE]
  C -->|"f50f f50d f510 f517<br/><b>new</b>"| L[LamzuAtlantisHidClient]
  C -->|anything else| P[Pulsar 4K receiver]
  L --> S["brand: Lamzu · LOD 1 mm / 2 mm<br/>rate table with both 1000 Hz encodings"]
  P --> Q["brand: Pulsar · LOD Low/Med/High<br/>0x10 decoded as 2000 Hz"]
Loading

Three things the wire does not do by the book

The battery reply lies about its length. Byte 4 says 0x02; four bytes follow. Decoding by the declared length silently drops the voltage, so payload is the full window and declaredLength is reported separately.

04 00 00 00 02 | 64 01 10 82 | …      100%, charging, 4226 mV
             ^^ says 2        ^^ sends 4

The percent byte is authoritative. lamzu-cfg derives the percentage linearly from millivolts between 3,050 and 4,200. At 4,239 mV that reads 100%, where the mouse's own byte — and Lamzu's configurator on screen next to it — both said 95%.

1,000 Hz has two encodings, as on the other Lamzu generations, so pulsarDecodePollingRate is wrong here: it reads 0x10 as 2,000 Hz.

Byte Rate Evidence
0x02 500 Vendor UI set to 500 Hz wrote this byte
0x01 1000 Vendor UI showed 1000 Hz over the cable
0x10 1000 Same UI, same reading, before it rewrote the profile
0x20 0x40 0x80 2000 / 4000 / 8000 Receiver family, shared Lamzu table

Reads decode both DPI axes

pulsarVgnDecodeDpi returns null unless a stage's two axis bytes are identical — the Pulsar driver only ever writes them in lockstep. A Lamzu configured with separate x and y stores a perfectly valid, correctly checksummed field that would read as corrupt, so reads use a Lamzu decoder that pulls each axis's high bits out of the flags byte (2-3 for x, 6-7 for y). Writes still go through the shared encoder, which flattens the axes; asymmetric writes aren't attempted without hardware to confirm them.

Flash fields

sleepTime (173) is absent from lamzu-cfg's map. Found by diffing the flash image across a change in Lamzu's configurator — 0x06 at a 1-minute timeout, 0x01 at 10 seconds — so the byte counts ten-second units, not seconds.

A field carries a trailing checksum, so value bytes plus that byte sum to 0x55:

// a read asks for one byte more than the field is wide and rejects a bad sum
const field = await this.readRaw(address, length + 1);
if (!lamzuAtlantisFieldIsIntact(field)) throw new Error(`…corrupt value from address ${address}.`);

Identity

Six models share 0x3554:0xf50f, one USB product string (LAMZU Atlantis Pro) and one firmware version. Nothing on the wire separates them, and Lamzu's own configurator solves this by asking the user to pick the model from a list — so the catalog names the family, Lamzu Atlantis, rather than guessing a model.

Hardware

Lamzu Atlantis Mini 4K, firmware 1.24 (0x1201 24, matching the USB bcdDevice and Lamzu's download page), wired 0x3554:0xf50f.

Read name, firmware, battery % + mV + charging, profile, 5 DPI stages and their colours, active stage, polling rate, lift-off, debounce, sleep, motion sync, angle snapping, ripple, competition mode, high performance — each checked against Lamzu's configurator open on the same mouse
Written, then restored polling 500→125→500, lift-off Low→Medium→Low, debounce 4→6→4 ms, sleep 60→30→60 s, motion sync, angle snapping, ripple, competition mode, high performance, active stage 2→0→2, stage value 2000→1600→2000, profile 1→2→1
Probed four onboard profiles — indices 0-3 write and read back, 4 and above are rejected with status 1

0xf50f is marked verified: true. The three receiver ids come from Lamzu's device table and nothing more — no receiver has been exercised, so neither their rate lists nor the transport itself is established, and they stay verified: false until one is. The rate family is stored per product rather than inferred from the rate ceiling, so the 1K receiver (which tops out at 1,000 Hz exactly like the cable) is not silently assigned an encoding.

Not touched: button remapping, macros, pairing and factory reset. No command id outside the documented set was ever sent — the write path on this firmware includes a factory reset, so unknown ids were not probed.

Protocol groundwork: LeadSun/lamzu-cfg (Apache-2.0/MIT), reverse-engineered from an Atlantis Mini Pro. Full capture notes in docs/lamzu-atlantis-testing.md.

Companion app PR: OpenMouse-Project/openmouse#235

🤖 Generated with Claude Code

bpavlina and others added 5 commits September 9, 2026 00:34
The Atlantis family speaks CompX's report-8 interrupt protocol, not the
feature-report page/command protocol the 0x373e and 0x37b0 Lamzu models
use — the same protocol this package already implements for Pulsar's 4K
receiver, so the framing, checksum, command ids and 50-step DPI encoding
are imported from there rather than restated.

Verified on a Lamzu Atlantis Mini 4K, firmware 1.24, wired 0x3554:0xf50f.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWUM7JqBNz5bjMfs3ZVQKn
- A late reply to a timed-out attempt could satisfy the next request:
  every flash access shares command 0x08/0x07, so a read of one address
  could return another's bytes and a setter could verify against the
  wrong field. Flash replies are now matched on the echoed address.
- open() is memoized; two concurrent reads attached two listeners, of
  which close() removed one.
- setDpiStageCount shrinks the cached stage list with the device.
- setProfile validates against the four profiles the mouse accepts
  (0-3 write and read back; 4 and above are rejected with status 1).
- A corrupt DPI stage read throws instead of reporting 50 DPI.
- Dropped the redundant per-product picker filters: 0x3554 is already
  requested vendor-wide for the Pulsar 4K receiver.
- Discovery tests, and the flash-field evidence table now says which
  fields the vendor UI confirmed and which were only round-tripped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWUM7JqBNz5bjMfs3ZVQKn
The Atlantis config collection carries only report 8, the request/reply
channel, so startNotifications answers false and the app keeps polling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWUM7JqBNz5bjMfs3ZVQKn
isSupported gates on an output report 8 on the 0xff02/usage-2
collection, and Chrome's collection shape is not the platform's — so
what navigator.hid actually reports is now in the notes rather than
assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWUM7JqBNz5bjMfs3ZVQKn
setDpiStageColor scaled an unchecked stage index straight into a flash
address: stage -8 landed on the DPI stages at 12 and stage 13 on the
button actions at 96, with the colour reading back cleanly from wherever
it wrote. Every stage index is now bounded before it becomes an address.

Also:
- Setters hold the queue across write and read-back, so two concurrent
  calls for one field can no longer verify against each other's writes.
- close() cancels: it settles the waiting exchange and stops queued work
  from reopening the device.
- Switching profiles or changing the stage count drops the cached
  status, which described the old profile or the wrong stage list.
- DPI stages decode per axis, so a stage Lamzu's app set to separate x
  and y no longer reads as corrupt.
- The polling-rate family is declared per product instead of inferred
  from the rate ceiling, which had quietly decided it for the 1K
  receiver nobody has tested.
- Timers come from globalThis, so the client is testable off-browser.
- A fake HID device with real flash state now covers the request
  lifecycle, the setters, and each of the above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWUM7JqBNz5bjMfs3ZVQKn
The reentrancy flag it replaced could not tell a nested call from an
unrelated one: a second caller arriving while an operation awaited a
reply read the flag as nesting, ran inline, and overwrote the in-flight
exchange's reply callback. Public methods now take the lock exactly once
and work through unqueued helpers, so there is nothing to detect.

Also:
- open() and close() take turns. Overlapping them let a reopen memoize a
  resolved open while the close was still about to remove the listener,
  wedging every later open.
- setProfile and setDpiStageCount drop the cache before the write rather
  than after the verification read, which could otherwise fail and leave
  a cache describing a device that had already changed.
- dpiY follows dpi through a stage write and a stage switch instead of
  keeping the previous stage's value.
- The concurrency and close tests now reach the failure modes they name:
  the second caller starts only once the device has a report in hand and
  its reply is withheld. Reintroducing the old flag fails the first;
  restoring it passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWUM7JqBNz5bjMfs3ZVQKn
@snekxs

snekxs commented Sep 11, 2026

Copy link
Copy Markdown
Member

This PR conflicts with main (other driver additions landed in the meantime touching the shared registry.ts file). I've resolved the conflicts (pure additive merge, no logic changes — the Atlantis driver already correctly scopes itself to its own product IDs under the shared 0x3554 CompX vendor id) and opened #91 as a replacement, which is verified passing. Since I can't push to this fork branch, closing this one in favor of #91 — thank you for the Lamzu Atlantis work!

@snekxs snekxs closed this in 27b9e60 Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants